跳到主要内容

Redis 缓存读写策略设计及常见问题

缓存数据的处理流程是怎样的?

下图左侧为客户端直接调用存储层的架构,右侧为比较典型的缓存层+存储层架构。

简单来说就是:

  1. 如果用户请求的数据在缓存中就直接返回。
  2. 缓存中不存在的话就看数据库中是否存在。
  3. 数据库中存在的话就更新缓存中的数据。
  4. 数据库中不存在的话就返回空数据。

缓存的收益和成本分析

缓存加入后带来的收益和成本。

收益:

  1. 加速读写:因为缓存通常都是全内存的,而存储层通常读写性能不够强悍(例如 MySQL),通过缓存的使用可以有效地加速读写,优化用户体验。
  2. 降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL语句),在很大程度降低了后端的负载。

成本:

  1. 数据不一致性:缓存层和存储层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关。

  2. 代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本。

  3. 运维成本:以 Redis Cluster 为例,加入后无形中增加了运维成本。

缓存的使用场景基本包含如下两种:

  • 开销大的复杂计算:以 MySQL 为例子,一些复杂的操作或者计算(例如大量联表操作、一些分组计算),如果不加缓存,不但无法满足高并发量,同时也会给 MySQL 带来巨大的负担。

  • 加速请求响应:即使查询单条后端数据足够快(例如 `select from tablewhere id=` ),那么依然可以使用缓存,以 Redis 为例子,每秒可以完成数万次读写,并且提供的批量操作可以优化整个 IO链的响应时间。*

缓存更新策略

缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新,下面会介绍几种主要的缓存更新策略:

1、LRU/LFU/FIFO算法剔除:剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。例如 Redis 使用 maxmemory-policy 这个配置作为内存最大值后对于数据的剔除策略。

2、超时剔除:通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如 Redis 提供的 expire 命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个视频的描述信息,可以容忍几分钟内数据不一致,但是涉及交易方面的业务,后果可想而知。

3、主动更新:应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。

有两个建议:

  1. 低一致性业务建议配置最大内存和淘汰策略的方式使用。
  2. 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。

默认情况下,Redis 使用的是 LRU(最近最少使用)算法。

提示

要修改 Redis 的缓存淘汰算法,可以使用 Redis 的配置文件进行相应的更改。以下是修改 Redis 缓存淘汰算法的步骤:

Redis 是一个流行的键值存储系统,它支持多种数据结构,并且具有内置的缓存功能。在 Redis 中,缓存淘汰算法的选择由配置参数决定。默认情况下,Redis 使用的是 LRU(最近最少使用)算法。

  1. 打开 Redis 配置文件:打开 Redis 的配置文件,通常位于 redis.conf 文件中。

  2. 定位缓存淘汰相关的配置:在配置文件中搜索以下参数,它们与缓存淘汰算法有关:

    • maxmemory-policy:这个参数定义了当内存达到上限时,Redis 应该如何选择要剔除的键。默认值是 noeviction,表示不剔除任何键。其他可用选项包括 allkeys-lru(使用 LRU 算法)、allkeys-lfu(使用 LFU 算法)、allkeys-random(随机选择键)等。
    • maxmemory-samples:这个参数定义了每次检查内存使用情况时要考虑的键的数量。默认值是 5。
  3. 修改缓存淘汰算法:根据你的需求,选择适合的缓存淘汰算法。将 maxmemory-policy 参数的值修改为你所需的算法,如 allkeys-lfuallkeys-random 等。

  4. 保存并关闭配置文件:在修改完配置后,保存并关闭 Redis 配置文件。

  5. 重启 Redis 服务:重新启动 Redis 服务,以使配置更改生效。

注意:在修改 Redis 的缓存淘汰算法时,需要考虑到应用程序对缓存的访问模式和需求。不同的算法适用于不同的场景,因此选择合适的算法能够提高缓存性能和效果。

缓存粒度控制

缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很多无用空间的浪费,网络带宽的浪费,代码通用性较差等情况,需要综合数据通用性、空间占用比、代码维护性三点进行取舍。

缓存比较常用的选型,缓存层选用 Redis,存储层选用 MySQL。

假如我现在需要对视频的信息做一个缓存,也就是需要对 select * from video where id=? 的每个 id 在 redis 里做一份缓存,这样 cache 层就可以帮助我抗住很多的访问量(注:这里不讨论一致性和架构等等问题,只讨论缓存的粒度问题)。

我们假设视频表有100个属性,那么问题来了,需要缓存什么维度呢,也就是有两种选择吧:

catch(id)=select * from video where id=#id
catch(id)=select importantColumn1, importantColumn2 .. importantColumnN from video where id=#id 12

其实这个问题就是缓存粒度问题,我们在缓存设计应该佮预估和考虑呢?下面我们将从通用性、空间、代码维护三个角度进行说明。

全部数据和部分数据比较

实际上需要从下面多个角度分析:

  • 通用性:有个问题就是是否有必要缓存全部数据,哪些不重要的字段基本不会在 cache 里出现,也就是说着中通用性,不过这通常都是想象出来的。
  • 空间占用:很显然,缓存全部数据,会占用大量的内存,有人会说,不就费一点内存吗,能有多少钱?而且已经有人习惯了把缓存当做下水道来使用,什么都框框的往里面放,但是内存并不是免费的,可以说是很珍贵的资源。
  • 代码维护:代码维护性,全部数据的优势更加明显,而部分数据一旦要加新字段就会修改代码,而且还需要对原来的数据进行刷新。

总结:缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很多无用空间的浪费,可能会造成网络带宽的浪费,可能会造成代码通用性较差等情况,必须学会综合数据通用性、空间占用比、代码维护性 三点评估取舍因素权衡使用。

缓存雪崩

如果缓在某一个时刻出现大规模的 key 失效,那么就会导致大量的请求打在了数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量把数据库打死。这就是缓存雪崩。

造成缓存雪崩的关键在于同一时间的大规模的 key 失效,为什么会出现这个问题,主要有两种可能:

  1. 第一种是 Redis 宕机
  2. 第二种可能就是 采用了相同的过期时间

搞清楚原因之后,那么有什么解决方案呢?

事前预防

1、均匀过期:设置不同的过期时间,让缓存失效的时间尽量均匀,避免相同的过期时间导致缓存雪崩,造成大量数据库的访问。

2、分级缓存:第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。

3、热点数据缓存永远不过期。永不过期实际包含两层意思:

  • 物理不过期,针对热点 key 不设置过期时间
  • 逻辑过期,把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建

此方法有效杜绝了热点 key 产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。

4、保证 Redis 缓存的高可用,防止 Redis 宕机导致缓存雪崩的问题。可以使用 主从+ 哨兵,Redis 集群来避免 Redis 全盘崩溃的情况。

事中处理

1、互斥锁:在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量,比如某个 key 只允许一个线程查询数据和写缓存,其他线程等待。这种方式会阻塞其他的线程,此时系统的吞吐量会下降

2、使用熔断机制,限流降级。当流量达到一定的阈值,直接返回 “系统拥挤” 之类的提示,防止过多的请求打在数据库上将数据库击垮,至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。

事后恢复

开启 Redis 持久化机制,尽快恢复缓存数据,一旦重启,就能从磁盘上自动加载数据恢复内存中的数据。

缓存击穿

缓存击穿跟缓存雪崩有点类似,缓存雪崩是大规模的 key 失效,而缓存击穿是某个热点的 key 失效,大并发集中对其进行请求,就会造成大量请求读缓存没读到数据,从而导致高并发访问数据库,引起数据库压力剧增。这种现象就叫做缓存击穿。

关键在于某个热点的key失效了,导致大并发集中打在数据库上。所以要从两个方面解决,第一是否可以考虑热点 key 不设置过期时间,第二是否可以考虑降低打在数据库上的请求数量。

所以有如下两种解决方案(其实和雪崩一样的处理):

1、互斥锁:在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量,比如某个 key 只允许一个线程查询数据和写缓存,其他线程等待。这种方式会阻塞其他的线程,此时系统的吞吐量会下降

2、热点数据缓存永远不过期。永不过期实际包含两层意思:

  • 物理不过期,针对热点 key 不设置过期时间
  • 逻辑过期,把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建

缓存穿透

缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库,这也是经常提的缓存命中率问题。

通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

造成缓存穿透的基本原因有两个:

  • 第一,自身业务代码或者数据出现问题
  • 第二,一些恶意攻击、爬虫等造成大量空命中。

缓存穿透的关键在于在 Redis 中查不到key值,它和缓存击穿的根本区别在于传进来的 key 在 Redis 中是不存在的。假如有黑客传进大量的不存在的 key,那么大量的请求打在数据库上是很致命的问题,所以在日常开发中要对参数做好校验,一些非法的参数,不可能存在 的key 就直接返回错误提示。

下面我们来看一下如何解决缓存穿透问题。

1、将无效的 key 存放进 Redis 中当出现 Redis 查不到数据,数据库也查不到数据的情况,我们就把这个 key 保存到 Redis 中,设置 value="null",并设置其过期时间极短,后面再出现查询这个 key 的请求的时候,直接返回 null,就不需要再查询数据库了。但这种处理方式是有问题的,假如传进来的这个不存在的 Key 值每次都是随机的,那存进 Redis 也没有意义。

2、使用布隆过滤器如果布隆过滤器判定某个 key 不存在布隆过滤器中,那么就一定不存在,如果判定某个 key 存在,那么很大可能是存在(存在一定的误判率)。于是我们可以在缓存之前再加一个布隆过滤器,将数据库中的所有 key 都存储在布隆过滤器中,在查询 Redis 前先去布隆过滤器查询 key 是否存在,如果不存在就直接返回,不让其访问数据库,从而避免了对底层存储系统的查询压力。

备注

我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。

即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

如何选择:针对一些恶意攻击,攻击带过来的大量 key 是随机,那么我们采用第一种方案就会缓存大量不存在 key 的数据。那么这种方案就不合适了,我们可以先对使用布隆过滤器方案进行过滤掉这些 key。所以,针对这种 key 异常多、请求重复率比较低的数据,优先使用第二种方案直接过滤掉。而对于空数据的 key 有限的,重复率比较高的,则可优先采用第一种方式进行缓存。

缓存预热

缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。

如果不进行预热,那么 Redis 初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。

缓存预热解决方案:

  • 数据量不大的时候,工程启动的时候进行加载缓存动作;
  • 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
  • 数据量太大的时候,优先保证热点数据进行提前加载到缓存。

缓存降级

缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。降级一般是有损的操作,所以尽量减少降级对于业务的影响程度。

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。

降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。

在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:

  • 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
  • 警告:有些服务在一段时间内成功率有波动(如在 95~100% 之间),可以自动降级或人工降级,并发送告警;
  • 错误:比如可用率低于 90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
  • 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

旁路缓存模式

Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。

Cache Aside Pattern 中服务端需要同时维系 DB 和 cache,并且是以 DB 的结果为准。

下面我们来看一下这个策略模式下的缓存读写步骤。

读写步骤

写 :

  • 先更新 DB
  • 然后直接删除 cache

读 :

  • 从 cache 中读取数据,读取到就直接返回
  • cache中读取不到的话,就从 DB 中读取数据返回
  • 再把数据放到 cache 中。

常见问题

1、在写数据的过程中,可以先删除 cache,后更新 DB 么?

那肯定是不行的!因为这样可能会造成数据库(DB)和缓存(Cache)数据不一致的问题。为什么呢?比如说请求1 先写数据A,请求2 随后读数据A 的话就很有可能产生数据不一致性的问题。这个过程可以简单描述为(错误的):

请求1 先把 cache 中的 A数据删除 -> 请求2 从 DB 中读取数据 -> 请求1 再把 DB 中的 A数据更新。

2、在写数据的过程中,先更新 DB,后删除 cache 就没有问题了么?

理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多!比如请求1 先读数据 A,请求2 随后写数据A,并且数据A 不在缓存中的话也有可能产生数据不一致性的问题。这个过程可以简单描述为(错误的):

请求1 从 DB 读数据A -> 请求2 写更新数据 A 到数据库并删除 cache 中的 A数据 -> 请求1 将数据A(第一次读到的错误数据) 写入 cache。

缺陷及解决方法

1、首次请求数据一定不在 cache 的问题

解决办法:

  • 可以将热点数据可以提前放入cache 中。

2、写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率。

解决办法:

  • 数据库和缓存数据强一致场景 :更新 DB 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。
  • 可以短暂地允许数据库和缓存数据不一致的场景 :更新 DB 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。

读写穿透模式

Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。

这种缓存读写策略在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入DB的功能。

读写步骤

写(Write Through):

  • 先查 cache,cache 中不存在,直接更新 DB。
  • cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)。

读(Read Through):

  • 从 cache 中读取数据,读取到就直接返回。
  • 读取不到的话,先从 DB 加载,写入到 cache 后返回响应。

Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。

和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中

异步缓存写入模式

Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。

但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。

很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 DB 的话,cache 服务可能就就挂掉了。

这种策略也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制都用到了这种策略。

Write Behind Pattern 下 DB 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。

搭配 Nginx 设计的缓存机制

Reference